第十四章|项目二:WebCodecs 视频抽帧与水印
实现浏览器端视频处理主链路:MP4 demux、EncodedVideoChunk、VideoDecoder、VideoFrame、Canvas/OffscreenCanvas 抽帧与水印、VideoEncoder 重新编码、mux 输出与 Worker 化工程边界。
第十四章|项目二:WebCodecs 视频抽帧与水印
这一章开始从“知道 API”进入“能做项目”。目标不是把一个完整商业级剪辑器一次性做完,而是做出一条浏览器端视频处理主链路:
MP4 文件
↓
demux 解封装,拿到视频 sample
↓
VideoDecoder 解码
↓
得到 VideoFrame
↓
Canvas / OffscreenCanvas 绘制帧
↓
抽帧生成缩略图
↓
添加水印
↓
VideoEncoder 重新编码
↓
得到 EncodedVideoChunk
↓
mux 封装成 MP4 / WebM
WebCodecs 提供的是浏览器原生的底层音视频编解码能力,可以表示原始视频帧 VideoFrame、编码后视频块 EncodedVideoChunk,并通过 VideoDecoder / VideoEncoder 在二者之间转换;它适合视频编辑、直播、视频会议等需要逐帧控制的场景。(MDN Web Docs) 但 WebCodecs 不负责从 MP4 里读出 chunk,也不负责把 chunk 写成可播放的视频文件,所以本项目必须把 demuxer 和 muxer 当成独立模块。(MDN Web Docs)
1. 本章学习目标
学完本章,你应该能讲清楚并做出一个 Demo:
- 用户上传 MP4 后,为什么不能直接丢给
VideoDecoder。 - demuxer 在 WebCodecs pipeline 里的位置。
- 如何把 MP4 sample 转成
EncodedVideoChunk。 - 如何用
VideoDecoder得到VideoFrame。 - 如何每隔 1 秒抽一帧生成缩略图。
- 如何用 Canvas / OffscreenCanvas 给每帧加水印。
- 如何用
VideoEncoder重新编码。 - 为什么编码后的 chunks 还不是一个标准 MP4 文件。
- 为什么必须及时
VideoFrame.close()。 - 为什么真实项目里应该把处理放到 Worker。
2. 项目最终效果
项目名:
webcodecs-frame-watermark
核心功能:
1. 上传一个 MP4 文件
2. 解析出视频轨道
3. 使用 WebCodecs 解码视频帧
4. 每隔 1 秒生成一张缩略图
5. 对每帧添加文字水印
6. 使用 WebCodecs 重新编码视频
7. 输出 encoded chunks
8. 接入 muxer 后导出新的视频文件
界面可以先做得很简单:
+------------------------------------------------+
| 选择视频文件 |
| [ Upload MP4 ] |
+------------------------------------------------+
| 处理进度:37% |
| 当前阶段:decoding / processing / encoding |
+------------------------------------------------+
| 缩略图列表 |
| [00:00] [00:01] [00:02] [00:03] ... |
+------------------------------------------------+
| 处理结果 |
| encoded chunks: 184 |
| output: waiting for muxer |
+------------------------------------------------+
3. 核心心智模型
3.1 WebCodecs 只管“编解码”
很多新人会误以为:
const chunks = await videoEncoder.encode(...)
saveAsMp4(chunks)
这条路是错的。
正确心智模型是:
MP4 文件不是一堆纯视频帧。
MP4 = 容器。
里面有:
- ftyp
- moov
- trak
- stbl
- mdat
- 视频 sample
- 音频 sample
- 时间戳表
- codec 配置信息
所以:
VideoDecoder 需要的是 EncodedVideoChunk
而不是整个 MP4 文件。
VideoEncoder 输出的是 EncodedVideoChunk
而不是 MP4 文件。
一句面试版回答:
WebCodecs 负责 codec 层,也就是把 encoded chunk 解码成 VideoFrame,或者把 VideoFrame 编码成 encoded chunk。MP4/WebM 这种容器的解析和生成属于 demux/mux,不是 WebCodecs 的职责。
3.2 本项目的模块边界
┌──────────────────────────────┐
│ Main Thread │
│ - 文件选择 │
│ - UI 状态 │
│ - 展示缩略图 │
│ - 下载结果 │
└──────────────┬───────────────┘
│ postMessage(ArrayBuffer)
↓
┌──────────────────────────────┐
│ Worker │
│ │
│ 1. MP4 Demuxer │
│ MP4 → video samples │
│ │
│ 2. VideoDecoder │
│ samples → VideoFrame │
│ │
│ 3. Frame Processor │
│ VideoFrame → Canvas 水印 │
│ │
│ 4. Thumbnail Generator │
│ 每 1 秒导出一张 JPEG │
│ │
│ 5. VideoEncoder │
│ VideoFrame → chunks │
│ │
│ 6. Muxer │
│ chunks → MP4/WebM │
└──────────────────────────────┘
WebCodecs 可用于 Dedicated Web Worker,适合这种重处理任务;VideoEncoder 在 MDN 上也明确标注可在 Dedicated Web Worker 中使用,同时属于需要安全上下文的 API。(MDN Web Docs)
4. 必须掌握的术语表
| 术语 | 含义 | 本项目里在哪里出现 |
|---|---|---|
| demux | 从容器里拆出音视频 sample | MP4 → video samples |
| mux | 把 encoded chunks 写回容器 | chunks → MP4/WebM |
| sample | 容器层里的编码数据单元 | MP4 video sample |
| EncodedVideoChunk | WebCodecs 里的编码视频块 | 喂给 VideoDecoder,或由 VideoEncoder 输出 |
| VideoFrame | 解码后的原始视频帧 | Canvas 绘制、水印处理 |
| timestamp | 帧的展示时间,WebCodecs 里通常用微秒 | 抽帧、同步、编码输出 |
| duration | 当前帧持续多久 | 传递给新生成的 VideoFrame |
| key frame | 关键帧,可独立解码 | seek、解码起点、编码 GOP |
| codec string | 精确 codec 标识,如 avc1.4d0034 | decoder / encoder configure |
| description | codec 初始化数据 | MP4 中 H.264 常见来自 avcC |
VideoFrame 带有 timestamp、duration、格式和分辨率等元数据,并且可以渲染到 Canvas;这些 timestamp / duration 在 MDN 文档中以微秒为单位描述。(MDN Web Docs)
5. 推荐项目目录结构
webcodecs-frame-watermark/
├─ package.json
├─ tsconfig.json
├─ index.html
├─ src/
│ ├─ main.ts
│ ├─ style.css
│ ├─ types.ts
│ ├─ worker/
│ │ └─ video-worker.ts
│ ├─ demux/
│ │ ├─ mp4-demuxer.ts
│ │ └─ mp4-types.ts
│ ├─ mux/
│ │ └─ muxer-adapter.ts
│ ├─ codecs/
│ │ └─ webcodecs-pipeline.ts
│ ├─ processing/
│ │ ├─ watermark.ts
│ │ └─ thumbnail.ts
│ └─ utils/
│ ├─ time.ts
│ └─ queue.ts
这一章里的 demuxer / muxer 用 adapter 表示。原因很现实:完整 MP4 demuxer / muxer 不是几十行代码能严肃写完的,它涉及 box tree、sample table、codec config、chunk offset、timescale、fragment 等内容。前面的 Chapter 13 已经做了 MP4 Inspector,本章重点放在 WebCodecs pipeline。
6. 核心数据结构
新建:
// src/types.ts
export type ChunkType = "key" | "delta";
export interface VideoTrackInfo {
id: number;
codec: string;
codedWidth: number;
codedHeight: number;
displayWidth: number;
displayHeight: number;
timescale: number;
/**
* 对 H.264 in MP4,通常是 avcC 里的 AVCDecoderConfigurationRecord。
* 对不同 codec,description 的含义不同。
*/
description?: Uint8Array;
durationUs?: number;
frameRate?: number;
}
export interface DemuxedVideoSample {
data: Uint8Array;
/**
* WebCodecs timestamp 使用微秒。
* 对 MP4 来说,一般由 sample 的 composition time 转换而来。
*/
timestampUs: number;
durationUs: number;
type: ChunkType;
}
export interface WatermarkOptions {
text: string;
fontSizeRatio: number;
marginRatio: number;
opacity: number;
}
export interface ProcessOptions {
watermark: WatermarkOptions;
thumbnailIntervalUs: number;
outputCodec: string;
bitrate: number;
framerate: number;
}
export type WorkerRequest =
| {
type: "start";
buffer: ArrayBuffer;
options: ProcessOptions;
}
| {
type: "cancel";
};
export type WorkerResponse =
| {
type: "ready";
}
| {
type: "progress";
phase: "demuxing" | "decoding" | "processing" | "encoding" | "muxing" | "done";
progress: number;
}
| {
type: "thumbnail";
timestampUs: number;
mimeType: string;
buffer: ArrayBuffer;
}
| {
type: "encoded-chunk";
timestampUs: number;
typeOfChunk: ChunkType;
byteLength: number;
}
| {
type: "done";
output?: ArrayBuffer;
mimeType?: string;
encodedChunkCount: number;
}
| {
type: "error";
message: string;
stack?: string;
};
7. Main Thread:上传文件并交给 Worker
// src/main.ts
import type { WorkerRequest, WorkerResponse, ProcessOptions } from "./types";
const fileInput = document.querySelector<HTMLInputElement>("#file")!;
const progressEl = document.querySelector<HTMLDivElement>("#progress")!;
const thumbsEl = document.querySelector<HTMLDivElement>("#thumbs")!;
const logEl = document.querySelector<HTMLPreElement>("#log")!;
const worker = new Worker(new URL("./worker/video-worker.ts", import.meta.url), {
type: "module",
});
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const msg = event.data;
switch (msg.type) {
case "ready":
log("Worker ready");
break;
case "progress":
progressEl.textContent = `${msg.phase}: ${(msg.progress * 100).toFixed(1)}%`;
break;
case "thumbnail": {
const blob = new Blob([msg.buffer], { type: msg.mimeType });
const url = URL.createObjectURL(blob);
const item = document.createElement("figure");
const img = document.createElement("img");
const caption = document.createElement("figcaption");
img.src = url;
img.width = 160;
caption.textContent = formatUs(msg.timestampUs);
item.append(img, caption);
thumbsEl.append(item);
break;
}
case "encoded-chunk":
log(
`chunk ${msg.typeOfChunk}, ts=${formatUs(msg.timestampUs)}, bytes=${msg.byteLength}`,
);
break;
case "done":
log(`Done. encoded chunks = ${msg.encodedChunkCount}`);
if (msg.output && msg.mimeType) {
const blob = new Blob([msg.output], { type: msg.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = msg.mimeType.includes("mp4")
? "watermarked.mp4"
: "watermarked.webm";
a.textContent = "下载处理后的视频";
document.body.append(a);
}
break;
case "error":
log(`Error: ${msg.message}\n${msg.stack ?? ""}`);
break;
}
};
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file) return;
thumbsEl.innerHTML = "";
logEl.textContent = "";
if (!("VideoDecoder" in globalThis) || !("VideoEncoder" in globalThis)) {
log("当前浏览器不支持 WebCodecs VideoDecoder / VideoEncoder");
return;
}
const buffer = await file.arrayBuffer();
const options: ProcessOptions = {
watermark: {
text: "WebCodecs Demo",
fontSizeRatio: 0.035,
marginRatio: 0.03,
opacity: 0.72,
},
thumbnailIntervalUs: 1_000_000,
outputCodec: "avc1.42001f",
bitrate: 2_000_000,
framerate: 30,
};
const request: WorkerRequest = {
type: "start",
buffer,
options,
};
worker.postMessage(request, [buffer]);
});
function log(text: string) {
logEl.textContent += `${text}\n`;
}
function formatUs(us: number): string {
const totalSeconds = Math.floor(us / 1_000_000);
const mm = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
const ss = String(totalSeconds % 60).padStart(2, "0");
return `${mm}:${ss}`;
}
这里用 postMessage(request, [buffer]) 把 ArrayBuffer transfer 到 Worker。大文件视频不要复制来复制去,不然很容易造成主线程卡顿和内存暴涨。
8. Demuxer Adapter:把 MP4 变成 video samples
这里先定义接口:
// src/demux/mp4-demuxer.ts
import type { DemuxedVideoSample, VideoTrackInfo } from "../types";
export interface Mp4Demuxer {
getVideoTrack(): Promise<VideoTrackInfo>;
videoSamples(): AsyncIterable<DemuxedVideoSample>;
}
/**
* 这是一个 adapter 壳。
*
* 真实项目中你可以:
* 1. 接入现成 demuxer
* 2. 基于 Chapter 13 的 MP4 box parser 继续实现 sample table 解析
* 3. 用 WASM / JS demuxer
*/
export class Mp4DemuxerAdapter implements Mp4Demuxer {
constructor(private readonly buffer: ArrayBuffer) {}
async getVideoTrack(): Promise<VideoTrackInfo> {
// 伪代码:
// 1. 解析 ftyp / moov
// 2. 找到 video trak
// 3. 从 stsd 中拿 codec box,例如 avc1 / hvc1 / av01
// 4. 从 avcC / hvcC / av1C 中拿 decoder description
// 5. 读取 width / height / timescale / duration
throw new Error("请在这里接入真实 MP4 demuxer");
}
async *videoSamples(): AsyncIterable<DemuxedVideoSample> {
// 伪代码:
// for each sample in video track:
// const data = readSampleBytesFromMdat(sample.offset, sample.size)
// const timestampUs = timescaleToUs(sample.cts, track.timescale)
// const durationUs = timescaleToUs(sample.duration, track.timescale)
// const type = sample.isSync ? "key" : "delta"
// yield { data, timestampUs, durationUs, type }
throw new Error("请在这里输出 DemuxedVideoSample");
}
}
export function timescaleToUs(value: number, timescale: number): number {
return Math.round((value * 1_000_000) / timescale);
}
这里最容易卡在哪里?
MP4 里的时间不是天然用微秒表示,而是用 track 自己的 timescale 表示。
例如:
timescale = 90000
sample cts = 180000
换算成秒:
180000 / 90000 = 2 秒
换算成 WebCodecs 常用的微秒:
2 * 1_000_000 = 2_000_000 us
所以 demuxer 输出给 WebCodecs 的 sample 应该带上:
{
timestampUs: 2_000_000,
durationUs: 33_333,
}
9. H.264 in MP4 的一个重点:description
如果 MP4 里是 H.264,通常 sample data 是 AVC 格式,不是 Annex B 格式。对 WebCodecs 来说,H.264 的 VideoDecoderConfig.description 如果存在,会被当作 AVCDecoderConfigurationRecord;这个格式常见于 MP4 文件。(W3C)
简化理解:
MP4 里的 avcC box
↓
取出 AVCDecoderConfigurationRecord
↓
放进 VideoDecoder.configure({ description })
伪代码:
const decoderConfig: VideoDecoderConfig = {
codec: track.codec, // 例如 "avc1.4d401f"
codedWidth: track.codedWidth,
codedHeight: track.codedHeight,
description: track.description,
};
const support = await VideoDecoder.isConfigSupported(decoderConfig);
if (!support.supported) {
throw new Error(`Decoder not supported: ${track.codec}`);
}
decoder.configure(support.config);
这也是为什么“手写 MP4 parser 只解析 box 名字”还不够。想喂给 WebCodecs,需要拿到 codec string、sample bytes、timestamp、duration、keyframe 信息,以及 codec 初始化数据。
10. Watermark:把 VideoFrame 绘制到 OffscreenCanvas
// src/processing/watermark.ts
import type { WatermarkOptions } from "../types";
export class WatermarkRenderer {
private readonly canvas: OffscreenCanvas;
private readonly ctx: OffscreenCanvasRenderingContext2D;
constructor(
private readonly width: number,
private readonly height: number,
) {
this.canvas = new OffscreenCanvas(width, height);
const ctx = this.canvas.getContext("2d");
if (!ctx) {
throw new Error("Cannot create 2D context from OffscreenCanvas");
}
this.ctx = ctx;
}
render(frame: VideoFrame, options: WatermarkOptions): VideoFrame {
const { ctx, canvas, width, height } = this;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(frame, 0, 0, width, height);
const fontSize = Math.max(16, Math.round(width * options.fontSizeRatio));
const margin = Math.round(width * options.marginRatio);
ctx.save();
ctx.globalAlpha = options.opacity;
ctx.font = `${fontSize}px sans-serif`;
ctx.textBaseline = "bottom";
const metrics = ctx.measureText(options.text);
const x = width - metrics.width - margin;
const y = height - margin;
// 描边让水印在亮色 / 暗色背景上都能看清。
ctx.lineWidth = Math.max(2, Math.round(fontSize / 8));
ctx.strokeStyle = "rgba(0, 0, 0, 0.75)";
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.strokeText(options.text, x, y);
ctx.fillText(options.text, x, y);
ctx.restore();
return new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration ?? undefined,
});
}
async exportThumbnail(
timestampUs: number,
type = "image/jpeg",
quality = 0.82,
): Promise<{
timestampUs: number;
mimeType: string;
buffer: ArrayBuffer;
}> {
const blob = await this.canvas.convertToBlob({
type,
quality,
});
return {
timestampUs,
mimeType: type,
buffer: await blob.arrayBuffer(),
};
}
}
注意顺序:
const processedFrame = watermarkRenderer.render(inputFrame, options);
encoder.encode(processedFrame);
processedFrame.close();
inputFrame.close();
VideoFrame.close() 会释放对底层媒体资源的引用;这不是“可有可无的优化”,而是 WebCodecs 项目里非常关键的内存管理动作。(MDN Web Docs) W3C 规范也提示 codec 使用的 CPU/GPU/硬件资源可能会很快耗尽,不再需要时应尽快释放。(W3C)
11. Thumbnail:每隔 1 秒抽一帧
// src/processing/thumbnail.ts
export class ThumbnailScheduler {
private lastTimestampUs = Number.NEGATIVE_INFINITY;
constructor(private readonly intervalUs: number) {}
shouldCapture(timestampUs: number): boolean {
if (timestampUs - this.lastTimestampUs >= this.intervalUs) {
this.lastTimestampUs = timestampUs;
return true;
}
return false;
}
}
用 timestamp 判断,而不是用“第几帧”判断。
原因:
30fps 视频:
第 30 帧约等于 1 秒
24fps 视频:
第 24 帧约等于 1 秒
可变帧率视频:
第 N 帧不一定能稳定对应某个时间点
所以抽帧逻辑应该更偏时间:
timestamp >= 上一次缩略图 timestamp + 1_000_000 微秒
12. Queue 工具:处理背压
WebCodecs 的编码和解码是异步队列模型。MDN 文档说明 configure()、decode()、encode()、flush() 等方法会进入异步处理队列,flush() 用于等待已经排队的任务完成。(MDN Web Docs) 项目里不能无限制地 decode() 或 encode(),否则队列和内存都会涨。
// src/utils/queue.ts
export async function waitUntil(
predicate: () => boolean,
intervalMs = 4,
): Promise<void> {
while (!predicate()) {
await sleep(intervalMs);
}
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
export async function waitForDecoderQueue(
decoder: VideoDecoder,
maxQueueSize = 8,
): Promise<void> {
await waitUntil(() => decoder.decodeQueueSize <= maxQueueSize);
}
export async function waitForEncoderQueue(
encoder: VideoEncoder,
maxQueueSize = 8,
): Promise<void> {
await waitUntil(() => encoder.encodeQueueSize <= maxQueueSize);
}
13. Muxer Adapter:为什么 encoded chunks 还不能直接保存?
VideoEncoder 输出的是:
EncodedVideoChunk
它里面有:
- 压缩后的视频字节
- timestamp
- duration
- type: key / delta
但 MP4 文件还需要:
- ftyp
- moov
- mvhd
- trak
- mdia
- minf
- stbl
- stsd
- stts
- stsc
- stsz
- stco / co64
- stss
- mdat
所以需要 muxer。
// src/mux/muxer-adapter.ts
export interface EncodedVideoSampleForMuxer {
data: Uint8Array;
timestampUs: number;
durationUs?: number;
type: "key" | "delta";
}
export interface MuxerAdapter {
addVideoChunk(
chunk: EncodedVideoChunk,
metadata?: EncodedVideoChunkMetadata,
): void;
finalize(): Promise<{
mimeType: string;
buffer: ArrayBuffer;
}>;
}
/**
* 教学版:只收集 chunks,不真正生成 MP4。
* 真实项目中这里应该接 MP4/WebM muxer。
*/
export class CollectOnlyMuxer implements MuxerAdapter {
private readonly chunks: EncodedVideoSampleForMuxer[] = [];
addVideoChunk(
chunk: EncodedVideoChunk,
_metadata?: EncodedVideoChunkMetadata,
): void {
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
this.chunks.push({
data,
timestampUs: chunk.timestamp,
durationUs: chunk.duration ?? undefined,
type: chunk.type,
});
}
async finalize(): Promise<{
mimeType: string;
buffer: ArrayBuffer;
}> {
// 注意:这不是标准视频文件,只是教学用二进制拼接。
// 真实 muxer 需要生成合法 MP4/WebM 容器结构。
const total = this.chunks.reduce((sum, item) => sum + item.data.byteLength, 0);
const output = new Uint8Array(total);
let offset = 0;
for (const item of this.chunks) {
output.set(item.data, offset);
offset += item.data.byteLength;
}
return {
mimeType: "application/octet-stream",
buffer: output.buffer,
};
}
get chunkCount(): number {
return this.chunks.length;
}
}
面试时可以这样说:
我这个 Demo 的核心是 WebCodecs 解码、逐帧处理和重新编码。真正导出 MP4 还需要 muxer,因为
EncodedVideoChunk只是编码数据,不包含 MP4 容器所需的 box、sample table、track metadata 和 chunk offset 等信息。
14. Worker:完整 pipeline 骨架
// src/worker/video-worker.ts
import type {
WorkerRequest,
WorkerResponse,
ProcessOptions,
DemuxedVideoSample,
VideoTrackInfo,
} from "../types";
import { Mp4DemuxerAdapter } from "../demux/mp4-demuxer";
import { WatermarkRenderer } from "../processing/watermark";
import { ThumbnailScheduler } from "../processing/thumbnail";
import { CollectOnlyMuxer } from "../mux/muxer-adapter";
import { waitForDecoderQueue, waitForEncoderQueue } from "../utils/queue";
let cancelled = false;
post({
type: "ready",
});
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const msg = event.data;
if (msg.type === "cancel") {
cancelled = true;
return;
}
if (msg.type !== "start") return;
cancelled = false;
try {
await runPipeline(msg.buffer, msg.options);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
post({
type: "error",
message: err.message,
stack: err.stack,
});
}
};
async function runPipeline(
buffer: ArrayBuffer,
options: ProcessOptions,
): Promise<void> {
assertWebCodecsAvailable();
postProgress("demuxing", 0);
const demuxer = new Mp4DemuxerAdapter(buffer);
const track = await demuxer.getVideoTrack();
const watermarkRenderer = new WatermarkRenderer(
track.displayWidth,
track.displayHeight,
);
const thumbnailScheduler = new ThumbnailScheduler(options.thumbnailIntervalUs);
const muxer = new CollectOnlyMuxer();
let decodedFrames = 0;
let encodedChunks = 0;
const pendingFrameTasks = new Set<Promise<void>>();
const decoder = new VideoDecoder({
output: (frame) => {
const task = handleDecodedFrame({
frame,
track,
options,
watermarkRenderer,
thumbnailScheduler,
encoder,
}).finally(() => {
pendingFrameTasks.delete(task);
});
pendingFrameTasks.add(task);
},
error: (error) => {
throw error;
},
});
const encoder = new VideoEncoder({
output: (chunk, metadata) => {
encodedChunks += 1;
muxer.addVideoChunk(chunk, metadata);
post({
type: "encoded-chunk",
timestampUs: chunk.timestamp,
typeOfChunk: chunk.type,
byteLength: chunk.byteLength,
});
},
error: (error) => {
throw error;
},
});
const decoderConfig: VideoDecoderConfig = {
codec: track.codec,
codedWidth: track.codedWidth,
codedHeight: track.codedHeight,
description: track.description,
};
const decoderSupport = await VideoDecoder.isConfigSupported(decoderConfig);
if (!decoderSupport.supported || !decoderSupport.config) {
throw new Error(`当前环境不支持解码:${track.codec}`);
}
decoder.configure(decoderSupport.config);
const encoderConfig: VideoEncoderConfig = {
codec: options.outputCodec,
width: track.displayWidth,
height: track.displayHeight,
bitrate: options.bitrate,
framerate: options.framerate,
hardwareAcceleration: "prefer-hardware",
};
const encoderSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!encoderSupport.supported || !encoderSupport.config) {
throw new Error(`当前环境不支持编码:${options.outputCodec}`);
}
encoder.configure(encoderSupport.config);
postProgress("decoding", 0);
for await (const sample of demuxer.videoSamples()) {
if (cancelled) break;
await waitForDecoderQueue(decoder, 8);
decoder.decode(toEncodedVideoChunk(sample));
decodedFrames += 1;
if (decodedFrames % 30 === 0) {
postProgress("decoding", estimateProgress(sample, track));
}
}
await decoder.flush();
// decoder.flush() 只保证 decoder 输出已经吐出来;
// output callback 里的异步 Canvas / thumbnail / encode 任务还要等。
await Promise.all(pendingFrameTasks);
postProgress("encoding", 0.95);
await encoder.flush();
decoder.close();
encoder.close();
postProgress("muxing", 0.98);
const output = await muxer.finalize();
post({
type: "done",
output: output.buffer,
mimeType: output.mimeType,
encodedChunkCount: encodedChunks,
});
postProgress("done", 1);
}
function toEncodedVideoChunk(sample: DemuxedVideoSample): EncodedVideoChunk {
return new EncodedVideoChunk({
type: sample.type,
timestamp: sample.timestampUs,
duration: sample.durationUs,
data: sample.data,
});
}
async function handleDecodedFrame(params: {
frame: VideoFrame;
track: VideoTrackInfo;
options: ProcessOptions;
watermarkRenderer: WatermarkRenderer;
thumbnailScheduler: ThumbnailScheduler;
encoder: VideoEncoder;
}): Promise<void> {
const {
frame,
options,
watermarkRenderer,
thumbnailScheduler,
encoder,
} = params;
try {
postProgress("processing", 0);
const processedFrame = watermarkRenderer.render(frame, options.watermark);
try {
if (thumbnailScheduler.shouldCapture(frame.timestamp)) {
const thumbnail = await watermarkRenderer.exportThumbnail(frame.timestamp);
post(
{
type: "thumbnail",
timestampUs: thumbnail.timestampUs,
mimeType: thumbnail.mimeType,
buffer: thumbnail.buffer,
},
[thumbnail.buffer],
);
}
await waitForEncoderQueue(encoder, 8);
const shouldForceKeyFrame = shouldInsertKeyFrame(frame.timestamp);
encoder.encode(processedFrame, {
keyFrame: shouldForceKeyFrame,
});
} finally {
processedFrame.close();
}
} finally {
frame.close();
}
}
function shouldInsertKeyFrame(timestampUs: number): boolean {
// 简单策略:每 2 秒插入一个关键帧。
// 真实项目中应该结合 framerate / GOP / seek 需求。
return timestampUs % 2_000_000 < 40_000;
}
function estimateProgress(sample: DemuxedVideoSample, track: VideoTrackInfo): number {
if (!track.durationUs) return 0;
return Math.min(sample.timestampUs / track.durationUs, 1);
}
function assertWebCodecsAvailable(): void {
if (!("VideoDecoder" in globalThis)) {
throw new Error("当前环境不支持 VideoDecoder");
}
if (!("VideoEncoder" in globalThis)) {
throw new Error("当前环境不支持 VideoEncoder");
}
}
function post(message: WorkerResponse, transfer?: Transferable[]): void {
self.postMessage(message, transfer ?? []);
}
function postProgress(
phase: Extract<WorkerResponse, { type: "progress" }>["phase"],
progress: number,
): void {
post({
type: "progress",
phase,
progress,
});
}
15. Timestamp 如何传递?
本项目最重要的一条规则:
demuxer 读出来的时间戳
↓
EncodedVideoChunk.timestamp
↓
VideoDecoder 输出的 VideoFrame.timestamp
↓
处理后新建 VideoFrame 时继续沿用 timestamp
↓
VideoEncoder 输出的 EncodedVideoChunk.timestamp
↓
muxer 根据 timestamp 写 sample timing table
也就是:
const chunk = new EncodedVideoChunk({
timestamp: sample.timestampUs,
duration: sample.durationUs,
data: sample.data,
type: sample.type,
});
const processedFrame = new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration ?? undefined,
});
不要这么干:
// 错误示例:用 Date.now() 生成媒体时间戳
new VideoFrame(canvas, {
timestamp: Date.now(),
});
Date.now() 是现实世界时间,不是媒体时间线。媒体时间线应该来自文件本身,或者来自你自己构造的稳定帧率逻辑。
16. 为什么要用 Worker?
因为这个项目有几件非常重的事情:
1. 读取大文件
2. demux
3. decode
4. Canvas 绘制
5. 逐帧添加水印
6. encode
7. mux
如果全部放主线程,用户会看到:
- 页面卡住
- 按钮点不动
- 缩略图不更新
- 进度条假死
- 浏览器提示页面无响应
Worker 版本的好处:
主线程:
- 管 UI
- 管交互
- 展示进度和缩略图
Worker:
- 管重计算
- 管 WebCodecs
- 管 Canvas 处理
- 管 mux/demux
17. 性能优化建议
17.1 控制队列长度
不要这样:
for await (const sample of demuxer.videoSamples()) {
decoder.decode(toEncodedVideoChunk(sample));
}
大文件会疯狂堆队列。
应该这样:
for await (const sample of demuxer.videoSamples()) {
await waitForDecoderQueue(decoder, 8);
decoder.decode(toEncodedVideoChunk(sample));
}
17.2 用 OffscreenCanvas
Worker 里无法直接操作 DOM canvas,但可以使用 OffscreenCanvas 做逐帧绘制。
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
17.3 及时 close VideoFrame
每个 VideoFrame 用完就关:
try {
// draw / encode / thumbnail
} finally {
frame.close();
}
VideoEncoder.close() 也会结束待处理工作并释放系统资源,MDN 对 VideoEncoder.close() 的描述就是释放相关系统资源。(MDN Web Docs)
17.4 缩略图不要生成太密
别每帧都导出 JPEG。
30fps,10 分钟视频:
30 * 60 * 10 = 18000 张图
每秒一张已经很多:
10 分钟视频:
60 * 10 = 600 张图
真实产品里通常还会限制最多生成多少张,比如最多 100 张。
17.5 大视频要分段处理
完整视频一次性读入内存是 Demo 做法。
真实项目应该考虑:
- 分片读取
- streaming demux
- 边 demux 边 decode
- 边 encode 边 mux
- 及时释放中间数据
18. 常见坑
坑 1:把 MP4 文件直接传给 VideoDecoder
错误理解:
MP4 = 视频编码数据
正确理解:
MP4 = 容器
VideoDecoder 要的是编码 chunk
坑 2:忘记 decoder description
H.264 in MP4 常常需要从 avcC 里拿初始化数据,配置给 decoder。
decoder.configure({
codec: "avc1.4d401f",
codedWidth: 1920,
codedHeight: 1080,
description: avcCBytes,
});
坑 3:timestamp 单位错了
MP4 track timescale:
90000
WebCodecs timestamp:
微秒
错误:
timestamp: sample.cts
正确:
timestamp: Math.round(sample.cts * 1_000_000 / timescale)
坑 4:处理后新建 VideoFrame 时丢了 timestamp
错误:
const processed = new VideoFrame(canvas);
正确:
const processed = new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration ?? undefined,
});
坑 5:忘记 close
错误:
function onFrame(frame: VideoFrame) {
encoder.encode(frame);
}
正确:
function onFrame(frame: VideoFrame) {
try {
encoder.encode(frame);
} finally {
frame.close();
}
}
坑 6:以为 encoded chunks 拼起来就是 MP4
错误:
const blob = new Blob(chunks, { type: "video/mp4" });
这通常不是合法 MP4。
正确:
EncodedVideoChunk[]
↓
muxer
↓
合法 MP4/WebM 文件
坑 7:忽略浏览器兼容性
VideoEncoder 在 MDN 上仍被标为 Limited availability,不是所有主流浏览器环境都完整可用;项目里应该用 VideoEncoder.isConfigSupported() / VideoDecoder.isConfigSupported() 做运行时检测。(MDN Web Docs)
19. 面试可能怎么问?
Q1:WebCodecs 能不能直接处理 MP4 文件?
不能。WebCodecs 处理的是 codec 层的数据,比如 EncodedVideoChunk 和 VideoFrame。MP4 是容器,需要先 demux,把里面的视频 sample、timestamp、codec config 拆出来,再喂给 VideoDecoder。
Q2:为什么 VideoEncoder 输出的 chunk 不能直接保存成 MP4?
因为 EncodedVideoChunk 只是压缩后的视频数据,不包含 MP4 容器结构。MP4 还需要 ftyp、moov、trak、stbl、mdat 等 box,以及 sample timing、chunk offset、sync sample 等表。要保存为 MP4,必须经过 muxer。
Q3:抽帧为什么用 timestamp,而不是用 frame index?
因为不同视频帧率不同,还有可变帧率视频。第 30 帧不一定永远等于 1 秒。用 timestamp 可以按真实媒体时间线抽帧,比如每隔 1_000_000us 抽一张。
Q4:水印处理后,为什么新建 VideoFrame 时要保留 timestamp?
因为编码器输出的新 chunk 需要保持原视频的时间线。如果处理后帧丢了 timestamp,muxer 后续就不知道每帧应该在什么时候展示,轻则播放速度异常,重则音视频不同步。
Q5:为什么要调用 VideoFrame.close()?
因为 VideoFrame 可能引用底层 CPU/GPU/解码器资源。逐帧处理视频时,如果不及时 close,内存和硬件资源会快速累积,可能导致卡顿、解码停滞或页面崩溃。
Q6:Worker 在这个项目里解决什么问题?
Worker 把 demux、decode、Canvas 绘制、encode、mux 等重任务从主线程移走,主线程只负责 UI 和交互。这样可以减少页面卡死,提高用户体验。
Q7:如果要保留原视频音频,怎么做?
有两种方案:
方案一:只处理视频轨,音频轨不解码,直接 remux
- demux 视频 sample
- demux 音频 sample
- 视频 decode/process/encode
- 音频 sample 原样写入 muxer
- mux 成新 MP4
方案二:音频也处理
- demux 音频 sample
- AudioDecoder 解码
- Web Audio / AudioData 处理
- AudioEncoder 重新编码
- mux 成新 MP4
如果只是加视频水印,通常优先选择方案一,避免音频重编码带来的质量损失和性能开销。
20. 实践任务
任务 1:先跑通浏览器能力检测
实现:
async function checkWebCodecs() {
const hasDecoder = "VideoDecoder" in globalThis;
const hasEncoder = "VideoEncoder" in globalThis;
if (!hasDecoder || !hasEncoder) {
return false;
}
const result = await VideoEncoder.isConfigSupported({
codec: "avc1.42001f",
width: 1280,
height: 720,
bitrate: 1_000_000,
framerate: 30,
});
return result.supported === true;
}
目标:
页面显示:
- 是否支持 VideoDecoder
- 是否支持 VideoEncoder
- 是否支持目标编码 codec
任务 2:接入 demuxer,打印 video track 信息
输出:
codec: avc1.4d401f
width: 1920
height: 1080
timescale: 90000
duration: 12.34s
description bytes: 45
任务 3:解码并抽取第一帧
先不要编码,先完成:
MP4 → demux → VideoDecoder → VideoFrame → Canvas → JPEG
成功标准:
页面上能展示视频第一帧
任务 4:每隔 1 秒生成缩略图
成功标准:
10 秒视频大约生成 10 张缩略图
每张图下面显示时间戳
任务 5:添加水印并重新编码
成功标准:
VideoEncoder output callback 能持续收到 EncodedVideoChunk
日志能看到 key / delta chunk
任务 6:接入 muxer 导出文件
成功标准:
点击下载
得到 watermarked.mp4 或 watermarked.webm
本地播放器可以播放
水印存在
时长正确
21. 自测题
题 1:WebCodecs 为什么不直接接受 MP4 文件?
答案:因为 MP4 是容器,里面包含 box、track、sample table、mdat 等结构。WebCodecs 的 VideoDecoder 接受的是编码后的视频 chunk,也就是 EncodedVideoChunk,所以需要 demuxer 先从 MP4 中拆出视频 sample。
题 2:VideoFrame.close() 应该什么时候调用?
答案:当这个帧已经不再需要时调用。比如已经绘制到 Canvas、已经生成新帧、或者已经传给 encoder 后,就应该 close。常见写法是放在 finally 中,避免异常路径泄漏资源。
题 3:为什么抽帧应该按 timestamp,而不是按第 N 帧?
答案:因为不同视频 fps 不同,还有可变帧率视频。timestamp 代表媒体时间线,用它可以稳定表达“每隔 1 秒抽一帧”。
题 4:重新编码后为什么还需要 muxer?
答案:因为编码器输出的是 EncodedVideoChunk,不是 MP4 文件。MP4 需要容器结构和 sample metadata,例如 track、duration、sample size、sample timing、chunk offset、sync sample 等信息。
题 5:处理后新建 VideoFrame 时最容易漏掉什么?
答案:timestamp 和 duration。漏掉后,编码输出的 chunk 可能没有正确媒体时间,后续 mux 和播放都会出问题。
22. 本章总结
这一章你做的是一个非常典型的浏览器端视频处理项目:
上传 MP4
↓
demux
↓
EncodedVideoChunk
↓
VideoDecoder
↓
VideoFrame
↓
Canvas / OffscreenCanvas
↓
抽帧 + 水印
↓
VideoEncoder
↓
EncodedVideoChunk
↓
mux
↓
导出文件
这一章最关键的 5 句话:
- WebCodecs 是 codec API,不是 MP4 parser。
- 输入 MP4 要 demux,输出 MP4 要 mux。
- timestamp 必须从 demuxer 一路传到 encoder / muxer。
- VideoFrame 用完必须 close。
- 真实项目应放到 Worker,避免卡主线程。
23. 下一章衔接
下一章是:
Chapter 15:项目三:Web Audio 多轨混音器
Chapter 14 解决的是视频帧处理:
VideoFrame → Canvas → VideoEncoder
Chapter 15 会解决音频合成:
AudioBuffer / AudioNode → OfflineAudioContext → WAV / encoded audio
学完 Chapter 14 和 Chapter 15,你就能把 JD 里的这几个关键词连起来:
WebCodecs
Web Audio
编解码
合成
抽帧
水印
混音
浏览器端音视频处理